上回已經做好了「讀取」,這次要來著手進行剩下的三個,有了先前的準備,準備了 AccountingRepository
這個負責與後端溝通的儲存庫,測試也有寫了,想必這次應該能加快速度,一次寫完 3 個功能了吧!
不試試看不知道,雖然沒有像梁靜茹那麼有勇氣,但總要對自己有信心吧! ⛳
雖然信心是有了,但需求依舊還是三個,一樣是分別進行:
首先,記帳 App 要能夠新增,於是寫了如下的使用者故事:
作為一名使用者
我希望可以儲存新增的帳目
以便下次回來回頭查找帳目
一樣請 AI 幫我列成 BDD feature 格式,轉出來的結果為:
Feature: 儲存新增的帳目
Scenario: 儲存一筆新的帳目
Given 應用程序已啟動
When 我新增一筆帳目,金額為 100 元,類別為「飲食」
Then 我應該看到該帳目已成功儲存
And 我應該能在我的帳目紀錄中找到這筆帳目
好了,那這次其實跟之前寫過的測試有點像,都是確保「新增帳目」之後,帳目有儲存起來,可以看得到「先前」的帳目紀錄。
於是我打算請 AI 參考舊有的測試,幫我根據此次新的需求,寫個新的測試:
幫我參考 adds new item to history when category is selected and confirms 這個測試,並按照 @create_and_save_accounting.feature 的需求,生成一筆新的測試。
此測試需要測得「新增之後」,再次 render 之後,還可以看得到該帳目。
根據此指令,AI(claude-sonnet)給了我看起來有模有樣的結果,但應該是有點問題 🤔 …
看到的主要問題點有兩個:
jest.clearAllMocks()
,這八成不是這樣寫,但我也不確定寫法,等等實作功能之後再回頭看看。it('saves a new accounting entry and displays it after re-rendering', async () => {
let historyList: HistoryItem[] = [];
const ACCOUNTING_ENTRY: HistoryItem = { id: 1, category: '飲食', amount: 100 };
// 首次渲染應用
await whenRender();
// 新增一筆帳目
whenInputNumber(ACCOUNTING_ENTRY.amount);
whenClickOK();
whenSelectCategory(ACCOUNTING_ENTRY.category);
whenClickOK();
// 確認帳目已新增
thenCategoryShouldHave(ACCOUNTING_ENTRY.category);
thenAmountShouldBe(`$${ACCOUNTING_ENTRY.amount}`);
// 模擬保存操作
const mockSaveEntry = jest.spyOn(AccountingRepositoryImpl.prototype, 'saveEntry').mockImplementation(async () => {
// 模擬保存操作,這裡可以將新帳目加入到歷史列表中
historyList.push(ACCOUNTING_ENTRY);
});
// 初始化空的歷史列表
jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue(historyList);
await waitFor(() => {
expect(mockSaveEntry).toHaveBeenCalledWith(expect.objectContaining({
category: ACCOUNTING_ENTRY.category,
amount: ACCOUNTING_ENTRY.amount
}));
});
// 清除 DOM 並重新渲染應用
jest.clearAllMocks();
jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue(historyList);
await whenRender();
// 確認重新渲染後仍能看到該帳目
await waitFor(() => {
thenCategoryShouldHave('飲食');
thenAmountShouldBe('$100');
});
});
AI 幫我把測試寫好了,而它「幻想」 AccountingRepository
會有個 saveEntry
的方法,負責把新增的帳目給記起來。但剛剛還沒有,所以測試直接報錯:「Property saveEntry
does not exist in the provided object」
既然 AI 生成的結果已經幫我預想好 class 的方法了,而我目前也沒什麼好想法,「應觀眾要求」就替 AccountingRepository
加上這個方法吧!
指令很簡單,在 AccountingRepository
這個檔案,開啟 Prompt 視窗,提供以下指令即可。
在 interface 加上 saveEntry 方法,實作亦同
但這樣只是把方法加上去還不夠,測試依舊沒通過,重點的驗測「saveEntry 會被呼叫」這個還沒通過,也就提醒了我根本還沒在 App 中實作儲存的功能。
原本想說要不要在 method 裡面下指令就好,或是用 useEffect 做處理?但不想要顧慮這麼多,就在 App 這個組件直接下指令。
幫我在其中實作 @AccountingRepository.ts 的 saveEntry 功能
AI 幫我直接在 handleSelectCategory
這個「確認」的方法,加上了 saveEntry
的處理。
const handleSelectCategory = async (category: string) => {
//...
// Save the entry using the repository
await accountingRepository.saveEntry({ amount: parsedAmount, category, id: Date.now() });
};
這樣一來,新增並儲存的功能已經實作於其中,再跑一下測試。很好,測試通過了!🟢
現在功能完成,測試也通過了,事不宜遲把剛剛有所疑慮的「 清除 DOM 並重新渲染應用」,這部分來問問 AI ,測試一下寫法 🤔
但在嘗試解除疑慮之前,看到一個寫法得先調整,否則此項測試就不可靠了,因為原有的測試可能沒有測到真正該側的邏輯。
要調整的是「準備資料」的呼叫順序,也就是 spyOn
的呼叫之處,要改為放在 whenRender()
之前。原先把 spyOn
放在 render 之後,都已經 render 完了,是要 spy 個毛 😂
接著,來試試看 jest.clearAllMocks()
到底有沒有如註解說明般那樣可以「清空畫面」。於是我先試著把準備資料那行的 jest.spyOn(…)
註解掉,如果「測試通過」,那代表根本沒有把畫面渲染給清除掉(沒把畫面刷新)。
jest.clearAllMocks();
// jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue(historyList);
await whenRender();
await waitFor(() => {
thenCategoryShouldHave('飲食');
thenAmountShouldBe('$100');
});
果不其然,測試還是通過了,表示畫面根本沒被清空,還是「前一個畫面」的組件。那到底要怎樣做,才會真的「刷新畫面」,確保組件是「全新」的一塊呢?
這部分輸入好多次指令,試了各種下指令的方法,AI 都沒有給出有用的回答。請 AI 幫忙做頗為挫折,無論怎麼給指令,都沒有做到真正刷新畫面。
AI 不是給 jest.clearAllMocks()
就是給 jest.resetModules()
之類的方法,完全都跟「重新刷新畫面」沒關係,這根本是雞同鴨講 XD
最後爬了一下資料,回頭看了 react-testing-library
這個套件的原始碼檔案,看到下方有個 cleanup()
的方法,可以把 mounted 的組件給「解除安裝」。
看來就是它了!於是試試看使用 cleanup()
方法取代原先的 jest.XXX 。
賓果 🎯! 就是這個方法沒錯! 在沒有準備 AccountingRepositoryImpl
回傳值的前提,測試會出錯。表示 cleanup()
之後再做 whenRender()
,會是個「全新的畫面」。
修改了一番之後,測試總算通過了(功能都沒動),可以預期「新增和讀取」都可以正常運作囉。
最後,測試重構一下,為了往後的測試方便共用,且增加其閱讀性,想要把 historyList 放到整個測試範圍層級,並且我想把 saveEntry 抽成共用的函式,於是請 AI 幫我「代勞」:
幫我重構
saves a new accounting entry and displays it after re-rendering
這個測試的
let historyList: HistoryItem[] = [];
和
const mockSaveEntry = jest.spyOn(AccountingRepositoryImpl.prototype, 'saveEntry').mockImplementation(async () => {
historyList.push(ACCOUNTING_ENTRY);
});
幫我重構為整個測試共用的函式
最後產出的結果還算滿意,將 historyList 和 saveEntry 搬移到 beforeEach 的函式內,並且把相對應的測試做了調整。重構完之後,測試依舊是「好的」,很好!
describe('AccountingApp', () => {
let historyList: HistoryItem[];
let mockSaveEntry: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
historyList = [];
jest.spyOn(AccountingRepositoryImpl.prototype, 'getEntries').mockResolvedValue([]);
mockSaveEntry = jest.spyOn(AccountingRepositoryImpl.prototype, 'saveEntry')
.mockImplementation(async (entry) => {
historyList.push({ ...entry, id: historyList.length + 1 });
});
});
至此為止,PDCA 小循環做開發新功能,大家經過兩次的範例,如果也有自己試著做做看,應該有點感覺了。
雖然還剩下刪除和修改這兩個功能,但今天就到此為止。最後,就用這個範例做結尾。畢竟再繼續介紹下去,各位可以學到知識的邊際效益便不高了。
剩下的兩個功能,大家有興趣可以自己試著做做看呦!
這次與上次最不一樣的地方,在於「承接上一次」的開發結果,接續著先前的程式碼結果做開發。
你會想說,這兩篇提到的,不就跟更早之前文章提到的開發過程差不多?哪有什麼差別?
是沒有什麼突破性的差別,但我們把「開發時程縮短」,讓單個功能的循環週期,變得清楚可見於單篇文章,而非散落在各篇文章中。
尤其循環的最後(Action),是個覆盤的好機會,放眼過去(此次),展望未來(接下來的開發)。檢視這次的循環產出的程式碼,有沒有可能造成「下一次」開發的困難?
我在做 Read 的時候,接著要做 Create,那 Read 做完的最後,有沒有可以調整,以便之後更方便一點?
此次 Create 開發完成後,再接著做 Delete 或是 Update,是不是有什麼地方可以改得更好,測試有沒有要為「緊接而來」的需求做調整?
這沒有標準答案,也沒有什麼標準 SOP,這仰賴當下開發的需求而定,也取決於舊有程式碼的脈絡而定。
蠻需要靠「想像力」與「經驗」來決定如何調整:想像如果下一個(下個就好,不要想太遠)功能加進來的話,這邊是不是要寫得抽象一點? 有沒有需要提前重構好準備資料?
如果不確定,可以請 AI 幫忙寫寫看,先請 AI 幫忙描繪「想像中程式的樣子」,如果覺得不適合,大不了就砍掉 Code 而已。現在生成程式碼如此「便宜又快速」,無須多慮,不好就砍。